Dependency Injection in .NET for industrial WPF systems
Dependency Injection sounds simple when people explain it in small demo apps.
“Register services, inject them into constructors, done.”
But in a real industrial desktop system, DI is not just a convenience feature. It becomes part of how you control complexity, manage long-lived objects, isolate hardware concerns, and stop the application from turning into a giant ball of tightly-coupled code.
For a system like:
“A production-grade WPF desktop application that controls, monitors, and visualizes results from a wafer inspection machine”
DI is not mainly about elegance. It is about survival.
PART 1 — BIG PICTURE
What problem DI solves in real systems
In a real desktop machine-control app, you do not have just a few classes.
You usually have things like:
- machine connection services
- recipe management services
- inspection workflow engines
- image/result processing pipelines
- real-time event streams
- alarm management
- audit logging
- persistence
- user/session services
- dozens of ViewModels
- device adapters for cameras, PLCs, motion controllers, sensors
All of these pieces depend on each other in some way.
Without DI, object creation spreads everywhere. A ViewModel creates a workflow service. That workflow service creates a machine client. That machine client creates a TCP transport. Another screen creates its own machine client. A background monitor creates yet another one. Soon you have:
- duplicated connections
- inconsistent configuration
- hidden dependencies
- impossible-to-test code
- random shared state
- object lifetime bugs
DI solves this by moving object creation to one central composition layer.
Instead of every class deciding how to build its dependencies, each class only says what it needs.
That sounds small, but the architectural effect is huge.
The class stops being responsible for wiring, configuration, and infrastructure decisions. It can focus on its real job.
Why manual object creation fails at scale
Manual object creation works fine when the system is tiny.
For example:
var machineClient = new MachineClient(new TcpTransport("10.0.0.5", 9000));
var workflow = new InspectionWorkflow(machineClient, logger);
var vm = new MainViewModel(workflow);This is okay in a toy sample.
But in a real system, the construction rules become complicated:
- some services must be single-instance for the whole app
- some must exist once per inspection run
- some must be recreated on reconnect
- some must be lazily created only when a feature opens
- some depend on runtime settings or selected machine type
- some need clean shutdown and disposal
- some must run in background threads
- some must be isolated per UI screen
- some must not be shared because they hold mutable state
Once that happens, manual new becomes scattered system wiring. And scattered wiring becomes architecture debt.
The pain is not just ugliness. The real pain is that lifecycle rules become implicit and inconsistent.
One developer thinks the machine communication service should be shared globally. Another screen creates its own instance. A third service caches state assuming a singleton. Now the app behaves differently depending on which screen was opened first.
That is how real production bugs happen.
Why DI is different in desktop vs web apps
This is a very important point.
In ASP.NET Core, DI is natural because the framework already gives you a clean execution model:
- app starts
- services are registered
- each request gets a scope
- request finishes
- scoped services are disposed
Desktop apps are different.
A WPF app is long-lived. There is no built-in request scope. The process may run for hours or days. Screens open and close. Machine sessions connect and reconnect. Inspection runs start and stop. Users log in and out without restarting the app.
That changes DI design a lot.
In a desktop app, the hard part is not registration. The hard part is lifetime design.
You need to think about lifetimes such as:
- application lifetime
- machine session lifetime
- inspection run lifetime
- window lifetime
- dialog lifetime
- tab lifetime
- background worker lifetime
Web apps get “request scope” for free. Desktop apps do not. In desktop, if you need a per-run or per-window scope, you usually design it yourself.
That is why DI in desktop systems is more architectural and less automatic.
Real examples
Machine communication services
You usually want one carefully controlled object managing connection state to a specific machine or subsystem. You do not want random parts of the app creating their own socket clients.
Workflow orchestration
An inspection workflow may depend on machine services, recipe validation, safety checks, event publishing, logging, and result storage. DI helps compose all of that without hardwiring it directly into the workflow class.
UI ViewModel composition
A screen may need services for commands, navigation, alarms, machine status, and current run state. DI makes the ViewModel readable and testable instead of turning it into a service creation hub.
PART 2 — HOW IT ACTUALLY WORKS
Let’s strip away the abstraction and talk about what the built-in .NET DI container is really doing.
Service registration
At startup, you add registrations to an IServiceCollection.
Example:
services.AddSingleton<IMachineConnectionManager, MachineConnectionManager>();
services.AddSingleton<IAlarmService, AlarmService>();
services.AddTransient<RecipeEditorViewModel>();
services.AddTransient<MainWindow>();This registration phase is basically building a set of rules.
Each rule says something like:
- when someone asks for
IMachineConnectionManager, createMachineConnectionManager - keep one instance for the app lifetime if it is singleton
- create a new one every time if it is transient
The container is not “running the app” yet. It is storing construction recipes.
Service resolution
When the app asks the container for something:
var mainWindow = serviceProvider.GetRequiredService<MainWindow>();the container starts resolving dependencies recursively.
Suppose MainWindow depends on MainViewModel, and MainViewModel depends on:
IMachineConnectionManagerIInspectionCoordinatorIAlarmService
and IInspectionCoordinator depends on:
IRecipeServiceIRunContextFactoryILogger<InspectionCoordinator>
The container walks the whole graph and builds it from the bottom up.
That is the core idea of dependency graph construction.
Dependency graph construction
Think of it like building a tree of objects.
If you ask for MainViewModel, the container may internally do something conceptually like this:
- Need
MainViewModel - To build it, need
IMachineConnectionManager,IInspectionCoordinator,IAlarmService - To build
IInspectionCoordinator, needIRecipeService,IRunContextFactory,ILogger - Resolve each of those
- Create
InspectionCoordinator - Create
MainViewModel
The container uses constructor injection by default. It looks for the constructor and resolves parameters.
Example:
public sealed class MainViewModel
{
public MainViewModel(
IMachineConnectionManager machineConnectionManager,
IInspectionCoordinator inspectionCoordinator,
IAlarmService alarmService)
{
_machineConnectionManager = machineConnectionManager;
_inspectionCoordinator = inspectionCoordinator;
_alarmService = alarmService;
}
private readonly IMachineConnectionManager _machineConnectionManager;
private readonly IInspectionCoordinator _inspectionCoordinator;
private readonly IAlarmService _alarmService;
}This is powerful because the dependency graph is explicit. You can read the constructor and immediately see what the object needs.
That clarity is one of the biggest practical benefits of DI.
PART 3 — REAL PROBLEMS IN THIS SYSTEM
Now let’s place this into the wafer inspection machine desktop app.
Managing machine instance lifecycle
This is where many teams get into trouble.
A machine service is usually not just a stateless helper. It may hold:
- socket connections
- device handles
- native library state
- callbacks
- subscriptions
- reconnection logic
- heartbeat timers
- background loops
That means its lifetime matters.
In many industrial apps, the machine connection manager should be a singleton at the application level, or at least a carefully controlled long-lived service.
Why?
Because the machine is a shared physical resource. You generally want one logical owner coordinating access to it.
Bad design looks like this:
- Main screen creates a machine client
- Diagnostics screen creates another machine client
- Result monitor creates another one
- Each thinks it owns the hardware
This causes conflicting commands, inconsistent state, duplicated subscriptions, and nasty debugging sessions.
A better design is usually:
- one singleton connection manager
- one or more typed device adapters under it
- controlled APIs for commands and state access
- explicit session state
Shared vs per-run services
This distinction is critical.
Some services should be shared across the app:
- machine connection manager
- alarm service
- application settings
- event bus
- logging infrastructure
- navigation service
Some services should exist per inspection run:
- run state
- cancellation token source
- result accumulator
- run-specific statistics
- per-run pipeline buffers
- run audit context
If you accidentally make run-specific services singleton, you get stale state bleeding across runs.
Real symptoms look like:
- defects from previous run appearing in current run
- cancellation state reused incorrectly
- old recipe data still attached
- memory never released after runs
- background handlers still referencing finished runs
In desktop systems, this is one of the biggest DI design problems: knowing what is app-wide and what is operation-scoped.
Because WPF has no built-in request scope, you often model this explicitly with a child scope.
Example idea:
using var runScope = _serviceScopeFactory.CreateScope();
var runController = runScope.ServiceProvider.GetRequiredService<InspectionRunController>();
await runController.RunAsync();Now everything registered as scoped can belong to that inspection run.
That is often a very clean pattern.
Background services and DI
Industrial desktop apps often have long-running background work such as:
- machine heartbeat monitoring
- alarm polling
- result ingestion
- image processing queues
- telemetry publishing
- watchdog services
In ASP.NET Core, hosted services are common and framework-supported. In WPF, you can still use the same abstractions, but you need to own startup and shutdown more deliberately.
For example, a background singleton service may start a loop and depend on other singleton infrastructure.
But you must think carefully about:
- startup order
- shutdown order
- cancellation
- exception handling
- thread affinity when pushing updates to UI
- whether the service depends on scoped state
A common mistake is making a singleton background service depend directly on scoped or transient state that should be per-run. That creates lifetime mismatches and hidden memory retention.
ViewModel creation and injection
ViewModels are often where desktop DI becomes messy.
In simple samples, people manually instantiate ViewModels in XAML or code-behind. That works for a while. Then the ViewModel needs five services. Then one of those services needs configuration. Then a dialog needs a specialized ViewModel with runtime parameters. Then navigation needs to create ViewModels dynamically.
Now manual creation becomes painful.
DI helps a lot, but ViewModels in desktop systems often need a mix of:
- injected services
- runtime data
Example:
public sealed class WaferDetailViewModel
{
public WaferDetailViewModel(
IDefectQueryService defectQueryService,
IImageLoader imageLoader,
WaferId waferId)
{
_defectQueryService = defectQueryService;
_imageLoader = imageLoader;
_waferId = waferId;
}
private readonly IDefectQueryService _defectQueryService;
private readonly IImageLoader _imageLoader;
private readonly WaferId _waferId;
}The container can resolve services, but it cannot automatically know the runtime WaferId.
That is why desktop apps often need factories, not just direct injection.
For example:
public interface IWaferDetailViewModelFactory
{
WaferDetailViewModel Create(WaferId waferId);
}This is much cleaner than turning the whole application into a service locator.
PART 4 — HOW WE USE IT IN .NET (PRACTICAL)
Let’s build a realistic shape for a WPF app using Microsoft.Extensions.DependencyInjection.
1. App startup
using System;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public partial class App : Application
{
private IHost? _host;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
ConfigureServices(services);
})
.Build();
await _host.StartAsync();
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
protected override async void OnExit(ExitEventArgs e)
{
if (_host is not null)
{
await _host.StopAsync(TimeSpan.FromSeconds(5));
_host.Dispose();
}
base.OnExit(e);
}
private static void ConfigureServices(IServiceCollection services)
{
services.AddLogging(builder => builder.AddDebug());
// Core infrastructure
services.AddSingleton<IUiDispatcher, WpfUiDispatcher>();
services.AddSingleton<IClock, SystemClock>();
services.AddSingleton<IAlarmService, AlarmService>();
// Machine services
services.AddSingleton<IMachineConnectionManager, MachineConnectionManager>();
services.AddSingleton<IMachineCommandService, MachineCommandService>();
services.AddSingleton<IMachineStatusStream, MachineStatusStream>();
// Workflows
services.AddSingleton<IInspectionCoordinator, InspectionCoordinator>();
services.AddScoped<InspectionRunContext>();
services.AddScoped<InspectionRunController>();
// ViewModels
services.AddSingleton<MainViewModel>();
services.AddTransient<RecipeEditorViewModel>();
services.AddTransient<MachineDiagnosticsViewModel>();
// Views
services.AddSingleton<MainWindow>();
}
}This is a common practical pattern in WPF now: use the generic host even in desktop apps.
Why it is useful:
- centralized composition root
- logging/configuration integration
- lifetime management
- optional hosted/background services
- cleaner startup/shutdown model
2. Injecting into MainWindow
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}This is good because the window does not know how to build the ViewModel.
3. Main ViewModel
public sealed class MainViewModel
{
private readonly IMachineConnectionManager _machineConnectionManager;
private readonly IInspectionCoordinator _inspectionCoordinator;
private readonly IAlarmService _alarmService;
public MainViewModel(
IMachineConnectionManager machineConnectionManager,
IInspectionCoordinator inspectionCoordinator,
IAlarmService alarmService)
{
_machineConnectionManager = machineConnectionManager;
_inspectionCoordinator = inspectionCoordinator;
_alarmService = alarmService;
}
public async Task ConnectAsync()
{
await _machineConnectionManager.ConnectAsync();
}
public async Task StartInspectionAsync()
{
await _inspectionCoordinator.StartAsync();
}
}The ViewModel is now focused on orchestration, not construction.
4. Machine connection manager as singleton
public interface IMachineConnectionManager : IAsyncDisposable
{
Task ConnectAsync();
Task DisconnectAsync();
bool IsConnected { get; }
}
public sealed class MachineConnectionManager : IMachineConnectionManager
{
private readonly ILogger<MachineConnectionManager> _logger;
private MachineClient? _client;
public MachineConnectionManager(ILogger<MachineConnectionManager> logger)
{
_logger = logger;
}
public bool IsConnected => _client?.IsConnected == true;
public async Task ConnectAsync()
{
if (_client is not null && _client.IsConnected)
return;
_client = new MachineClient("10.0.0.10", 9000);
await _client.ConnectAsync();
_logger.LogInformation("Machine connected.");
}
public async Task DisconnectAsync()
{
if (_client is null)
return;
await _client.DisposeAsync();
_client = null;
_logger.LogInformation("Machine disconnected.");
}
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
}This is a good candidate for singleton because it manages one shared logical resource.
5. Per-run scope for inspection runs
public interface IInspectionCoordinator
{
Task StartAsync();
}
public sealed class InspectionCoordinator : IInspectionCoordinator
{
private readonly IServiceScopeFactory _scopeFactory;
public InspectionCoordinator(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task StartAsync()
{
using var scope = _scopeFactory.CreateScope();
var runController = scope.ServiceProvider
.GetRequiredService<InspectionRunController>();
await runController.RunAsync();
}
}And then:
public sealed class InspectionRunContext
{
public Guid RunId { get; } = Guid.NewGuid();
public DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
public string RecipeName { get; set; } = string.Empty;
}public sealed class InspectionRunController
{
private readonly InspectionRunContext _context;
private readonly ILogger<InspectionRunController> _logger;
public InspectionRunController(
InspectionRunContext context,
ILogger<InspectionRunController> logger)
{
_context = context;
_logger = logger;
}
public async Task RunAsync()
{
_logger.LogInformation("Starting run {RunId}", _context.RunId);
// Run-specific logic here
await Task.Delay(500);
_logger.LogInformation("Finished run {RunId}", _context.RunId);
}
}This is a very useful pattern in desktop systems. It gives you a clean unit of lifetime for one operation.
6. Background services in desktop
You can use IHostedService in WPF too.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class MachineHeartbeatService : BackgroundService
{
private readonly IMachineConnectionManager _machineConnectionManager;
private readonly ILogger<MachineHeartbeatService> _logger;
public MachineHeartbeatService(
IMachineConnectionManager machineConnectionManager,
ILogger<MachineHeartbeatService> logger)
{
_machineConnectionManager = machineConnectionManager;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (_machineConnectionManager.IsConnected)
{
_logger.LogDebug("Heartbeat check...");
}
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Heartbeat loop failed.");
}
}
}
}Registration:
services.AddHostedService<MachineHeartbeatService>();This is practical, but remember: background services should not directly manipulate UI objects. They should publish state changes or use a UI dispatcher abstraction.
7. ViewModel factories for runtime parameters
public sealed record WaferId(string Value);
public sealed class WaferDetailViewModel
{
public WaferDetailViewModel(
IDefectQueryService defectQueryService,
IImageLoader imageLoader,
WaferId waferId)
{
DefectQueryService = defectQueryService;
ImageLoader = imageLoader;
WaferId = waferId;
}
public IDefectQueryService DefectQueryService { get; }
public IImageLoader ImageLoader { get; }
public WaferId WaferId { get; }
}Factory:
public interface IWaferDetailViewModelFactory
{
WaferDetailViewModel Create(WaferId waferId);
}
public sealed class WaferDetailViewModelFactory : IWaferDetailViewModelFactory
{
private readonly IDefectQueryService _defectQueryService;
private readonly IImageLoader _imageLoader;
public WaferDetailViewModelFactory(
IDefectQueryService defectQueryService,
IImageLoader imageLoader)
{
_defectQueryService = defectQueryService;
_imageLoader = imageLoader;
}
public WaferDetailViewModel Create(WaferId waferId)
{
return new WaferDetailViewModel(
_defectQueryService,
_imageLoader,
waferId);
}
}This keeps runtime creation explicit without injecting IServiceProvider everywhere.
PART 5 — COMMON MISTAKES (VERY REALISTIC)
Wrong service lifetimes
This is the most common serious DI bug in real systems.
Mistake 1: singleton service holding per-run mutable state
Example: InspectionCoordinator is singleton and stores current wafer, current recipe, current cancellation token, current statistics.
It seems convenient at first.
But now state survives between runs, concurrent operations can corrupt each other, and cleanup becomes unclear.
Production consequences:
- stale run state
- race conditions
- hard-to-reproduce defects
- memory never freed
- random UI showing previous run data
Mistake 2: transient service wrapping expensive hardware resources
If MachineClient or CameraAdapter is transient, the container may create fresh hardware-related objects repeatedly.
Production consequences:
- excessive reconnects
- handle leaks
- duplicated event subscriptions
- unnecessary native allocations
- unstable machine behavior
Mistake 3: long-lived object depending on shorter-lived one
A singleton depending on a scoped service is especially dangerous. In ASP.NET Core this often gets caught. In desktop apps, lifetime mistakes may be more subtle depending on how you create scopes.
Production consequences:
- disposed object access
- accidental promotion of short-lived state to app lifetime
- leaks due to captured references
Injecting everything everywhere
Some teams get excited about DI and start injecting ten or fifteen dependencies into every class.
That is not good design. That is usually a smell.
If a ViewModel needs twelve services, one of these is probably true:
- the ViewModel has too many responsibilities
- you have weak application boundaries
- orchestration logic is in the wrong layer
- some services should be grouped behind a more focused abstraction
DI should reveal design problems, not hide them.
A fat constructor is often telling you the class is doing too much.
Using service locator anti-pattern
This is very common in desktop apps:
public sealed class BadViewModel
{
private readonly IServiceProvider _serviceProvider;
public BadViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void DoSomething()
{
var machineService = _serviceProvider.GetRequiredService<IMachineCommandService>();
var recipeService = _serviceProvider.GetRequiredService<IRecipeService>();
}
}This defeats a lot of the value of DI.
Why it is bad:
- dependencies become hidden
- constructor no longer tells the truth
- testing gets worse
- failures happen later at runtime
- code becomes harder to reason about
Sometimes factories or controlled dynamic resolution are necessary. But injecting raw IServiceProvider into business logic or ViewModels should be rare.
Tight coupling through DI misuse
DI does not automatically make code loosely coupled.
You can still create tight coupling if your abstractions are bad.
Example:
IMachineServicebecomes a giant interface with 60 methods- every ViewModel depends on it
- the whole system is now coupled to a god abstraction
That is still bad design, just with interfaces.
Good DI depends on good boundaries.
A service should represent a clear responsibility, not just “stuff we wanted to inject.”
PART 6 — PERFORMANCE & TRADE-OFFS
Cost of DI resolution
For most desktop applications, built-in DI resolution cost is not the bottleneck.
Compared with:
- machine IO
- image processing
- disk access
- database calls
- UI rendering
- network latency
container resolution is usually tiny.
So in most cases, do not micro-optimize DI resolution first.
That said, it is not free.
Repeatedly resolving large transient object graphs in hot paths can create:
- extra allocations
- more GC pressure
- startup delays for dialogs/screens
- avoidable churn in high-frequency operations
In industrial apps, hot paths are often result processing or real-time visualization loops. Those should not repeatedly resolve deep graphs on every item.
Use DI to build pipelines or orchestrators, not to create objects for every pixel, frame, or tiny event in a tight loop.
Singleton vs transient trade-offs
Singleton
Good for:
- shared infrastructure
- stateless services
- expensive reusable objects
- machine/session managers
- caches
- configuration-driven services
Risks:
- accidental shared mutable state
- thread-safety requirements
- memory retained for app lifetime
- hard-to-test behavior if stateful
Transient
Good for:
- lightweight stateless helpers
- ViewModels for short-lived screens
- operation-specific objects
Risks:
- repeated allocations
- duplicated expensive initialization
- accidental multiple subscriptions/resources
- disposal complexity if created too often
Scoped
In desktop apps, scoped can be extremely useful if you define meaningful scopes such as:
- inspection run
- calibration session
- import/export job
- modal workflow
This is often the sweet spot for industrial software.
Memory implications
DI itself is not the memory problem. Wrong lifetimes are.
Common memory issues:
- singleton event subscriber keeping dead ViewModels alive
- singleton cache holding run data forever
- background service referencing closed windows
- transient disposable objects created outside proper scope
- long-lived services holding large image buffers
In machine systems with image data, this gets expensive quickly. A few wrong references can keep hundreds of MB alive longer than expected.
So when thinking about DI and memory, think less about “container overhead” and more about “what lifetime did we accidentally create?”
PART 7 — SENIOR ENGINEER THINKING
How experienced engineers design service boundaries
Experienced engineers do not start with “register everything.”
They start with ownership and lifecycle questions.
They ask:
- What is application-wide?
- What belongs to one machine connection?
- What belongs to one inspection run?
- What belongs only to one screen?
- Which objects are stateful?
- Which objects are expensive?
- Which objects need disposal?
- Which objects must be thread-safe?
That is the right way to design DI.
A good service boundary usually has:
- one clear responsibility
- a small, understandable API
- a meaningful lifetime
- explicit ownership of resources or state
How to choose correct lifetimes
A practical way to think about it:
Use singleton when:
- there should be one logical shared instance
- the service is stateless or intentionally shared
- it owns shared infrastructure
- it is safe for concurrent use
- keeping it alive for app lifetime is acceptable
Examples:
- machine connection manager
- app settings provider
- alarm router
- event aggregator
- navigation service
Use transient when:
- the object is lightweight
- no shared state is needed
- a fresh instance is desirable
- it does not own expensive resources
Examples:
- small mappers
- formatters
- short-lived ViewModels
- command objects
Use scoped when:
- the object belongs to a meaningful operation boundary
- state should live together and die together
- cleanup after one run/session/job matters
Examples:
- inspection run context
- run statistics collector
- run-specific result aggregator
- calibration workflow state
This mindset is much more useful than memorizing rules.
How to structure DI for large systems
A mature system usually has a clear composition root and modular registrations.
For example:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMachineLayer(this IServiceCollection services)
{
services.AddSingleton<IMachineConnectionManager, MachineConnectionManager>();
services.AddSingleton<IMachineCommandService, MachineCommandService>();
services.AddSingleton<IMachineStatusStream, MachineStatusStream>();
return services;
}
public static IServiceCollection AddWorkflowLayer(this IServiceCollection services)
{
services.AddSingleton<IInspectionCoordinator, InspectionCoordinator>();
services.AddScoped<InspectionRunContext>();
services.AddScoped<InspectionRunController>();
return services;
}
public static IServiceCollection AddPresentationLayer(this IServiceCollection services)
{
services.AddSingleton<MainViewModel>();
services.AddTransient<RecipeEditorViewModel>();
return services;
}
}Then startup stays clean:
services
.AddMachineLayer()
.AddWorkflowLayer()
.AddPresentationLayer();This matters in large systems because DI registration can otherwise become a 500-line dumping ground.
How to avoid over-engineering
This is important.
A lot of teams overdo DI.
They create interfaces for everything, factories for everything, abstractions on abstractions, and eventually the system becomes harder to understand than the original problem.
Senior engineers usually apply DI where it creates real value:
- infrastructure boundaries
- expensive/shared resources
- stateful services with important lifetimes
- test seams where mocking/faking matters
- modular composition
They do not force an interface for every tiny utility class.
A good rule is:
- abstract what varies
- abstract what is expensive to couple to
- abstract what must be replaced or tested
- do not abstract simple stable implementation details just because DI exists
Final mental model
In a real industrial WPF system, DI is not mainly about “clean code.”
It is about three things:
1. Controlled construction Classes stop creating each other randomly.
2. Controlled lifetimes You decide what lives for the app, the run, the screen, or the operation.
3. Controlled boundaries Hardware, workflows, UI, and infrastructure stay decoupled enough to evolve safely.
That is the real value.
If you want, next I can do the same style deep dive for Options/Configuration in desktop .NET, Hosted Services in WPF, or factory patterns with DI for runtime-created ViewModels and machine adapters.